from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from datetime import datetime, date, timedelta from sqlalchemy import or_, and_ from sqlalchemy.orm import selectinload, joinedload from models import ( db, TodoItem, TodoItemResponsible, TodoItemFollower, TodoAuditLog, TodoUserPref ) from utils.logger import get_logger from utils.ldap_utils import validate_ad_accounts import uuid todos_bp = Blueprint('todos', __name__) logger = get_logger(__name__) @todos_bp.route('', methods=['GET']) @jwt_required() def get_todos(): """Get todos with filtering and pagination""" try: identity = get_jwt_identity() page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) # Filters status = request.args.get('status') priority = request.args.get('priority') starred = request.args.get('starred', type=bool) due_from = request.args.get('due_from') due_to = request.args.get('due_to') search = request.args.get('search') view_type = request.args.get('view', 'all') # all, created, responsible, following # Base query with eager loading to prevent N+1 queries query = TodoItem.query.options( joinedload(TodoItem.responsible_users), joinedload(TodoItem.followers) ) # Apply view type filter if view_type == 'created': query = query.filter(TodoItem.creator_ad == identity) elif view_type == 'responsible': query = query.join(TodoItemResponsible).filter( TodoItemResponsible.ad_account == identity ) elif view_type == 'following': query = query.join(TodoItemFollower).filter( TodoItemFollower.ad_account == identity ) else: # all query = query.filter( or_( TodoItem.creator_ad == identity, TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity), TodoItem.followers.any(TodoItemFollower.ad_account == identity) ) ) # Apply filters if status: query = query.filter(TodoItem.status == status) if priority: query = query.filter(TodoItem.priority == priority) if starred is not None: query = query.filter(TodoItem.starred == starred) if due_from: query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date()) if due_to: query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date()) if search: query = query.filter( or_( TodoItem.title.contains(search), TodoItem.description.contains(search) ) ) # Order by due date and priority (MySQL compatible) query = query.order_by( TodoItem.due_date.asc(), TodoItem.priority.desc(), TodoItem.created_at.desc() ) # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) todos = [todo.to_dict() for todo in pagination.items] return jsonify({ 'todos': todos, 'total': pagination.total, 'page': page, 'per_page': per_page, 'pages': pagination.pages }), 200 except Exception as e: logger.error(f"Error fetching todos: {str(e)}") return jsonify({'error': 'Failed to fetch todos'}), 500 @todos_bp.route('/', methods=['GET']) @jwt_required() def get_todo(todo_id): """Get single todo details""" try: identity = get_jwt_identity() todo = TodoItem.query.options( joinedload(TodoItem.responsible_users), joinedload(TodoItem.followers) ).filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission if not todo.can_view(identity): return jsonify({'error': 'Access denied'}), 403 return jsonify(todo.to_dict()), 200 except Exception as e: logger.error(f"Error fetching todo {todo_id}: {str(e)}") return jsonify({'error': 'Failed to fetch todo'}), 500 @todos_bp.route('', methods=['POST']) @jwt_required() def create_todo(): """Create new todo""" try: identity = get_jwt_identity() claims = get_jwt() data = request.get_json() # Validate required fields if not data.get('title'): return jsonify({'error': 'Title is required'}), 400 # Parse due date if provided due_date = None if data.get('due_date'): try: due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() except ValueError: return jsonify({'error': 'Invalid due date format. Use YYYY-MM-DD'}), 400 # Create todo todo = TodoItem( id=str(uuid.uuid4()), title=data['title'], description=data.get('description', ''), status=data.get('status', 'NEW'), priority=data.get('priority', 'MEDIUM'), due_date=due_date, creator_ad=identity, creator_display_name=claims.get('display_name', identity), creator_email=claims.get('email', ''), starred=data.get('starred', False) ) db.session.add(todo) # Add responsible users responsible_accounts = data.get('responsible_users', []) if responsible_accounts: valid_accounts = validate_ad_accounts(responsible_accounts) for account in responsible_accounts: if account in valid_accounts: responsible = TodoItemResponsible( todo_id=todo.id, ad_account=account, added_by=identity ) db.session.add(responsible) # Add followers follower_accounts = data.get('followers', []) if follower_accounts: valid_accounts = validate_ad_accounts(follower_accounts) for account in follower_accounts: if account in valid_accounts: follower = TodoItemFollower( todo_id=todo.id, ad_account=account, added_by=identity ) db.session.add(follower) # Add audit log audit = TodoAuditLog( actor_ad=identity, todo_id=todo.id, action='CREATE', detail={'title': todo.title, 'due_date': str(due_date) if due_date else None} ) db.session.add(audit) db.session.commit() logger.info(f"Todo created: {todo.id} by {identity}") return jsonify(todo.to_dict()), 201 except Exception as e: db.session.rollback() logger.error(f"Error creating todo: {str(e)}") return jsonify({'error': 'Failed to create todo'}), 500 @todos_bp.route('/', methods=['PATCH']) @jwt_required() def update_todo(todo_id): """Update todo""" try: identity = get_jwt_identity() data = request.get_json() todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission if not todo.can_edit(identity): return jsonify({'error': 'Access denied'}), 403 # Track changes for audit changes = {} # Update fields if 'title' in data: changes['title'] = {'old': todo.title, 'new': data['title']} todo.title = data['title'] if 'description' in data: changes['description'] = {'old': todo.description, 'new': data['description']} todo.description = data['description'] if 'status' in data: changes['status'] = {'old': todo.status, 'new': data['status']} todo.status = data['status'] # Set completed_at if status is DONE if data['status'] == 'DONE' and not todo.completed_at: todo.completed_at = datetime.utcnow() elif data['status'] != 'DONE': todo.completed_at = None if 'priority' in data: changes['priority'] = {'old': todo.priority, 'new': data['priority']} todo.priority = data['priority'] if 'due_date' in data: old_due = str(todo.due_date) if todo.due_date else None if data['due_date']: todo.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date() new_due = data['due_date'] else: todo.due_date = None new_due = None changes['due_date'] = {'old': old_due, 'new': new_due} if 'starred' in data: changes['starred'] = {'old': todo.starred, 'new': data['starred']} todo.starred = data['starred'] # Update responsible users if 'responsible_users' in data: # Remove existing TodoItemResponsible.query.filter_by(todo_id=todo_id).delete() # Add new responsible_accounts = data['responsible_users'] if responsible_accounts: valid_accounts = validate_ad_accounts(responsible_accounts) for account in responsible_accounts: if account in valid_accounts: responsible = TodoItemResponsible( todo_id=todo.id, ad_account=account, added_by=identity ) db.session.add(responsible) changes['responsible_users'] = data['responsible_users'] # Update followers if 'followers' in data: # Remove existing TodoItemFollower.query.filter_by(todo_id=todo_id).delete() # Add new follower_accounts = data['followers'] if follower_accounts: valid_accounts = validate_ad_accounts(follower_accounts) for account in follower_accounts: if account in valid_accounts: follower = TodoItemFollower( todo_id=todo.id, ad_account=account, added_by=identity ) db.session.add(follower) changes['followers'] = data['followers'] # Add audit log if changes: audit = TodoAuditLog( actor_ad=identity, todo_id=todo.id, action='UPDATE', detail=changes ) db.session.add(audit) db.session.commit() logger.info(f"Todo updated: {todo_id} by {identity}") return jsonify(todo.to_dict()), 200 except Exception as e: db.session.rollback() logger.error(f"Error updating todo {todo_id}: {str(e)}") return jsonify({'error': 'Failed to update todo'}), 500 @todos_bp.route('/', methods=['DELETE']) @jwt_required() def delete_todo(todo_id): """Delete todo""" try: identity = get_jwt_identity() todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Only creator can delete if todo.creator_ad != identity: return jsonify({'error': 'Only creator can delete todo'}), 403 # Add audit log before deletion audit = TodoAuditLog( actor_ad=identity, todo_id=None, # Will be null after deletion action='DELETE', detail={'title': todo.title, 'deleted_todo_id': todo_id} ) db.session.add(audit) # Delete todo (cascades will handle related records) db.session.delete(todo) db.session.commit() logger.info(f"Todo deleted: {todo_id} by {identity}") return jsonify({'message': 'Todo deleted successfully'}), 200 except Exception as e: db.session.rollback() logger.error(f"Error deleting todo {todo_id}: {str(e)}") return jsonify({'error': 'Failed to delete todo'}), 500 @todos_bp.route('/batch', methods=['PATCH']) @jwt_required() def batch_update_todos(): """Batch update multiple todos""" try: identity = get_jwt_identity() data = request.get_json() todo_ids = data.get('todo_ids', []) updates = data.get('updates', {}) if not todo_ids or not updates: return jsonify({'error': 'Todo IDs and updates required'}), 400 updated_count = 0 errors = [] for todo_id in todo_ids: try: todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: errors.append({'todo_id': todo_id, 'error': 'Not found'}) continue if not todo.can_edit(identity): errors.append({'todo_id': todo_id, 'error': 'Access denied'}) continue # Apply updates if 'status' in updates: todo.status = updates['status'] if updates['status'] == 'DONE': todo.completed_at = datetime.utcnow() else: todo.completed_at = None if 'priority' in updates: todo.priority = updates['priority'] if 'due_date' in updates: if updates['due_date']: todo.due_date = datetime.strptime(updates['due_date'], '%Y-%m-%d').date() else: todo.due_date = None # Add audit log audit = TodoAuditLog( actor_ad=identity, todo_id=todo.id, action='UPDATE', detail={'batch_update': updates} ) db.session.add(audit) updated_count += 1 except Exception as e: errors.append({'todo_id': todo_id, 'error': str(e)}) db.session.commit() logger.info(f"Batch update: {updated_count} todos updated by {identity}") return jsonify({ 'updated': updated_count, 'errors': errors }), 200 except Exception as e: db.session.rollback() logger.error(f"Error in batch update: {str(e)}") return jsonify({'error': 'Batch update failed'}), 500 @todos_bp.route('//responsible', methods=['POST']) @jwt_required() def add_responsible_user(todo_id): """Add responsible user to todo""" try: identity = get_jwt_identity() data = request.get_json() if not data or 'ad_account' not in data: return jsonify({'error': 'AD account is required'}), 400 ad_account = data['ad_account'] # Get todo todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission if not todo.can_edit(identity): return jsonify({'error': 'No permission to edit this todo'}), 403 # Validate AD account valid_accounts = validate_ad_accounts([ad_account]) if ad_account not in valid_accounts: return jsonify({'error': 'Invalid AD account'}), 400 # Check if already responsible existing = TodoItemResponsible.query.filter_by( todo_id=todo_id, ad_account=ad_account ).first() if existing: return jsonify({'error': 'User is already responsible for this todo'}), 400 # Add responsible user responsible = TodoItemResponsible( todo_id=todo_id, ad_account=ad_account, added_by=identity ) db.session.add(responsible) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'responsible_users', 'action': 'add', 'ad_account': ad_account } ) db.session.add(audit) db.session.commit() logger.info(f"Added responsible user {ad_account} to todo {todo_id} by {identity}") return jsonify({'message': 'Responsible user added successfully'}), 201 except Exception as e: db.session.rollback() logger.error(f"Add responsible user error: {str(e)}") return jsonify({'error': 'Failed to add responsible user'}), 500 @todos_bp.route('//responsible/', methods=['DELETE']) @jwt_required() def remove_responsible_user(todo_id, ad_account): """Remove responsible user from todo""" try: identity = get_jwt_identity() # Get todo todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission if not todo.can_edit(identity): return jsonify({'error': 'No permission to edit this todo'}), 403 # Find responsible relationship responsible = TodoItemResponsible.query.filter_by( todo_id=todo_id, ad_account=ad_account ).first() if not responsible: return jsonify({'error': 'User is not responsible for this todo'}), 404 # Remove responsible user db.session.delete(responsible) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'responsible_users', 'action': 'remove', 'ad_account': ad_account } ) db.session.add(audit) db.session.commit() logger.info(f"Removed responsible user {ad_account} from todo {todo_id} by {identity}") return jsonify({'message': 'Responsible user removed successfully'}), 200 except Exception as e: db.session.rollback() logger.error(f"Remove responsible user error: {str(e)}") return jsonify({'error': 'Failed to remove responsible user'}), 500 @todos_bp.route('//followers', methods=['POST']) @jwt_required() def add_follower(todo_id): """Add follower to todo""" try: identity = get_jwt_identity() data = request.get_json() if not data or 'ad_account' not in data: return jsonify({'error': 'AD account is required'}), 400 ad_account = data['ad_account'] # Get todo todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission (anyone who can view the todo can add followers) if not todo.can_view(identity): return jsonify({'error': 'No permission to view this todo'}), 403 # Validate AD account valid_accounts = validate_ad_accounts([ad_account]) if ad_account not in valid_accounts: return jsonify({'error': 'Invalid AD account'}), 400 # Check if already following existing = TodoItemFollower.query.filter_by( todo_id=todo_id, ad_account=ad_account ).first() if existing: return jsonify({'error': 'User is already following this todo'}), 400 # Add follower follower = TodoItemFollower( todo_id=todo_id, ad_account=ad_account, added_by=identity ) db.session.add(follower) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'followers', 'action': 'add', 'ad_account': ad_account } ) db.session.add(audit) db.session.commit() logger.info(f"Added follower {ad_account} to todo {todo_id} by {identity}") return jsonify({'message': 'Follower added successfully'}), 201 except Exception as e: db.session.rollback() logger.error(f"Add follower error: {str(e)}") return jsonify({'error': 'Failed to add follower'}), 500 @todos_bp.route('//followers/', methods=['DELETE']) @jwt_required() def remove_follower(todo_id, ad_account): """Remove follower from todo""" try: identity = get_jwt_identity() # Get todo todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission (user can remove themselves or todo editors can remove anyone) if ad_account != identity and not todo.can_edit(identity): return jsonify({'error': 'No permission to remove this follower'}), 403 # Find follower relationship follower = TodoItemFollower.query.filter_by( todo_id=todo_id, ad_account=ad_account ).first() if not follower: return jsonify({'error': 'User is not following this todo'}), 404 # Remove follower db.session.delete(follower) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'followers', 'action': 'remove', 'ad_account': ad_account } ) db.session.add(audit) db.session.commit() logger.info(f"Removed follower {ad_account} from todo {todo_id} by {identity}") return jsonify({'message': 'Follower removed successfully'}), 200 except Exception as e: db.session.rollback() logger.error(f"Remove follower error: {str(e)}") return jsonify({'error': 'Failed to remove follower'}), 500 @todos_bp.route('//star', methods=['POST']) @jwt_required() def star_todo(todo_id): """Star/unstar a todo item""" try: identity = get_jwt_identity() # Get todo todo = TodoItem.query.filter_by(id=todo_id).first() if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check permission if not todo.can_view(identity): return jsonify({'error': 'No permission to view this todo'}), 403 # Only creator can star/unstar if todo.creator_ad != identity: return jsonify({'error': 'Only creator can star/unstar todos'}), 403 # Toggle star status todo.starred = not todo.starred # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'starred', 'value': todo.starred } ) db.session.add(audit) db.session.commit() action = 'starred' if todo.starred else 'unstarred' logger.info(f"Todo {todo_id} {action} by {identity}") return jsonify({ 'message': f'Todo {action} successfully', 'starred': todo.starred }), 200 except Exception as e: db.session.rollback() logger.error(f"Star todo error: {str(e)}") return jsonify({'error': 'Failed to star todo'}), 500