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 - show todos user can view (public + private with access) query = query.filter( or_( TodoItem.is_public == True, # All public todos TodoItem.creator_ad == identity, # Created by user TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity), # User is responsible TodoItem.followers.any(TodoItemFollower.ad_account == identity) # User is follower ) ) # 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), is_public=data.get('is_public', False), tags=data.get('tags', []) ) 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'] if 'is_public' in data: changes['is_public'] = {'old': todo.is_public, 'new': data['is_public']} todo.is_public = data['is_public'] if 'tags' in data: changes['tags'] = {'old': todo.tags, 'new': data['tags']} todo.tags = data['tags'] # 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 @todos_bp.route('/public', methods=['GET']) @jwt_required() def get_public_todos(): """Get all public todos""" try: page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) # Filters for public todos status = request.args.get('status') priority = request.args.get('priority') search = request.args.get('search') tags = request.args.getlist('tags') # Query only public todos query = TodoItem.query.filter(TodoItem.is_public == True).options( joinedload(TodoItem.responsible_users), joinedload(TodoItem.followers) ) # Apply filters if status: query = query.filter(TodoItem.status == status) if priority: query = query.filter(TodoItem.priority == priority) if search: query = query.filter( or_( TodoItem.title.contains(search), TodoItem.description.contains(search) ) ) if tags: for tag in tags: query = query.filter(TodoItem.tags.contains(tag)) # Order by created_at desc query = query.order_by(TodoItem.created_at.desc()) # Paginate paginated = query.paginate(page=page, per_page=per_page, error_out=False) return jsonify({ 'todos': [todo.to_dict() for todo in paginated.items], 'total': paginated.total, 'pages': paginated.pages, 'current_page': page }), 200 except Exception as e: logger.error(f"Get public todos error: {str(e)}") return jsonify({'error': 'Failed to fetch public todos'}), 500 @todos_bp.route('/following', methods=['GET']) @jwt_required() def get_following_todos(): """Get todos that user is following""" try: identity = get_jwt_identity() page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) # Query todos where user is a follower query = TodoItem.query.join(TodoItemFollower).filter( TodoItemFollower.ad_account == identity ).options( joinedload(TodoItem.responsible_users), joinedload(TodoItem.followers) ) # Order by created_at desc query = query.order_by(TodoItem.created_at.desc()) # Paginate paginated = query.paginate(page=page, per_page=per_page, error_out=False) return jsonify({ 'todos': [todo.to_dict() for todo in paginated.items], 'total': paginated.total, 'pages': paginated.pages, 'current_page': page }), 200 except Exception as e: logger.error(f"Get following todos error: {str(e)}") return jsonify({'error': 'Failed to fetch following todos'}), 500 @todos_bp.route('//visibility', methods=['PATCH']) @jwt_required() def update_todo_visibility(todo_id): """Toggle todo visibility (public/private)""" try: identity = get_jwt_identity() # Get todo todo = TodoItem.query.get(todo_id) if not todo: return jsonify({'error': 'Todo not found'}), 404 # Only creator can change visibility if todo.creator_ad != identity: return jsonify({'error': 'Only creator can change visibility'}), 403 # Toggle visibility data = request.get_json() is_public = data.get('is_public', not todo.is_public) todo.is_public = is_public # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UPDATE', detail={ 'field': 'is_public', 'old_value': not is_public, 'new_value': is_public } ) db.session.add(audit) db.session.commit() logger.info(f"Todo {todo_id} visibility changed to {'public' if is_public else 'private'} by {identity}") return jsonify({ 'message': f'Todo is now {"public" if is_public else "private"}', 'is_public': todo.is_public }), 200 except Exception as e: db.session.rollback() logger.error(f"Update visibility error: {str(e)}") return jsonify({'error': 'Failed to update visibility'}), 500 @todos_bp.route('//follow', methods=['POST']) @jwt_required() def follow_todo(todo_id): """Follow a public todo""" try: identity = get_jwt_identity() # Get todo todo = TodoItem.query.get(todo_id) if not todo: return jsonify({'error': 'Todo not found'}), 404 # Check if todo is public or user has permission if not todo.is_public and not todo.can_edit(identity): return jsonify({'error': 'Cannot follow private todo'}), 403 # Check if already following existing = TodoItemFollower.query.filter_by( todo_id=todo_id, ad_account=identity ).first() if existing: return jsonify({'message': 'Already following this todo'}), 200 # Add follower follower = TodoItemFollower( todo_id=todo_id, ad_account=identity, added_by=identity ) db.session.add(follower) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='FOLLOW', detail={'follower': identity} ) db.session.add(audit) db.session.commit() logger.info(f"User {identity} followed todo {todo_id}") return jsonify({'message': 'Successfully followed todo'}), 200 except Exception as e: db.session.rollback() logger.error(f"Follow todo error: {str(e)}") return jsonify({'error': 'Failed to follow todo'}), 500 @todos_bp.route('//follow', methods=['DELETE']) @jwt_required() def unfollow_todo(todo_id): """Unfollow a todo""" try: identity = get_jwt_identity() # Get follower record follower = TodoItemFollower.query.filter_by( todo_id=todo_id, ad_account=identity ).first() if not follower: return jsonify({'message': 'Not following this todo'}), 200 # Remove follower db.session.delete(follower) # Log audit audit = TodoAuditLog( actor_ad=identity, todo_id=todo_id, action='UNFOLLOW', detail={'follower': identity} ) db.session.add(audit) db.session.commit() logger.info(f"User {identity} unfollowed todo {todo_id}") return jsonify({'message': 'Successfully unfollowed todo'}), 200 except Exception as e: db.session.rollback() logger.error(f"Unfollow todo error: {str(e)}") return jsonify({'error': 'Failed to unfollow todo'}), 500