from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from sqlalchemy.orm import Session from typing import List, Optional from passlib.context import CryptContext from pathlib import Path from PIL import Image import os import hashlib from datetime import datetime from database import get_db from models.user import User, UserRole from models.project import ProjectMember from models.task import Task from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") router = APIRouter() def require_admin_permission_with_db( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Dependency to require admin permission.""" current_user = _get_user_from_db(db, token_data["user_id"]) if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required" ) return current_user def require_admin_or_coordinator( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Dependency to require admin permission or coordinator role.""" current_user = _get_user_from_db(db, token_data["user_id"]) if not current_user.is_admin and current_user.role != UserRole.COORDINATOR: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission or Coordinator role required" ) return current_user @router.post("/{user_id}/approve", response_model=dict) async def approve_user( user_id: int, approval_data: UserApproval, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Approve or disapprove a user account (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) user.is_approved = approval_data.is_approved db.commit() action = "approved" if approval_data.is_approved else "disapproved" return { "message": f"User {user.email} has been {action}", "user_id": user.id, "is_approved": user.is_approved } @router.put("/{user_id}/role", response_model=dict) async def update_user_role( user_id: int, role_data: UserRoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Update user role (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Prevent admin from changing their own role if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own role" ) user.role = role_data.role db.commit() return { "message": f"User {user.email} role updated to {role_data.role}", "user_id": user.id, "role": user.role } @router.put("/{user_id}/admin", response_model=dict) async def update_user_admin_permission( user_id: int, admin_data: UserAdminUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Grant or revoke admin permission (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Prevent user from revoking their own admin permission if they're the only admin if user.id == current_user.id and not admin_data.is_admin: admin_count = db.query(User).filter(User.is_admin == True).count() if admin_count <= 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke admin permission - you are the only admin user" ) user.is_admin = admin_data.is_admin db.commit() action = "granted" if admin_data.is_admin else "revoked" return { "message": f"Admin permission {action} for user {user.email}", "user_id": user.id, "is_admin": user.is_admin } @router.get("/", response_model=List[UserResponse]) async def list_users( skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(require_admin_or_coordinator) ): """List all users (Admin and Coordinator only).""" users = db.query(User).offset(skip).limit(limit).all() return users @router.get("/pending", response_model=List[UserResponse]) async def list_pending_users( db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """List users pending approval (Admin permission required).""" pending_users = db.query(User).filter(User.is_approved == False).all() return pending_users @router.get("/me", response_model=UserResponse) async def get_current_user_profile( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Get current user's profile.""" current_user = _get_user_from_db(db, token_data["user_id"]) return current_user @router.put("/me", response_model=UserResponse) async def update_current_user_profile( user_update: UserUpdate, token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Update current user's profile.""" current_user = _get_user_from_db(db, token_data["user_id"]) # Users can only update their own name, not role or approval status if user_update.first_name is not None: current_user.first_name = user_update.first_name if user_update.last_name is not None: current_user.last_name = user_update.last_name # Only users with admin permission can update role and approval status if user_update.role is not None or user_update.is_approved is not None or user_update.is_admin is not None: if not current_user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required to update role, approval status, or admin permission" ) if user_update.role is not None: current_user.role = user_update.role if user_update.is_approved is not None: current_user.is_approved = user_update.is_approved if user_update.is_admin is not None: current_user.is_admin = user_update.is_admin db.commit() db.refresh(current_user) return current_user @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin_or_coordinator) ): """Get user by ID (Admin and Coordinator only).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) return user @router.post("/admin/create", response_model=UserResponse, status_code=status.HTTP_201_CREATED) async def admin_create_user( user_data: UserAdminCreate, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Create a new user account (Admin permission required).""" # Check if email already exists existing_user = db.query(User).filter(User.email == user_data.email).first() if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) # Hash the password hashed_password = pwd_context.hash(user_data.password) # Create new user new_user = User( email=user_data.email, password_hash=hashed_password, first_name=user_data.first_name, last_name=user_data.last_name, role=user_data.role, is_approved=user_data.is_approved, is_admin=user_data.is_admin ) db.add(new_user) db.commit() db.refresh(new_user) return new_user @router.put("/{user_id}", response_model=UserResponse) async def admin_edit_user( user_id: int, user_data: UserAdminEdit, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Edit user account (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Check if email is being changed and if it's already in use if user_data.email and user_data.email != user.email: existing_user = db.query(User).filter(User.email == user_data.email).first() if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already in use" ) user.email = user_data.email # Prevent admin from removing their own admin permission if user_data.is_admin is not None and user.id == current_user.id and not user_data.is_admin: admin_count = db.query(User).filter(User.is_admin == True).count() if admin_count <= 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot remove your own admin permission - you are the only admin user" ) # Update fields if user_data.first_name is not None: user.first_name = user_data.first_name if user_data.last_name is not None: user.last_name = user_data.last_name if user_data.role is not None: user.role = user_data.role if user_data.is_approved is not None: user.is_approved = user_data.is_approved if user_data.is_admin is not None: user.is_admin = user_data.is_admin db.commit() db.refresh(user) return user @router.put("/{user_id}/password", response_model=dict) async def admin_reset_user_password( user_id: int, password_data: UserPasswordReset, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Reset user password (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Hash the new password hashed_password = pwd_context.hash(password_data.new_password) user.password_hash = hashed_password db.commit() return { "message": f"Password reset successfully for user {user.email}", "user_id": user.id } @router.get("/{user_id}/can-delete", response_model=dict) async def check_user_can_delete( user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Check if a user can be deleted (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Check if trying to delete themselves if user.id == current_user.id: return { "can_delete": False, "reason": "Cannot delete your own account", "project_memberships": 0, "task_assignments": 0 } # Check for project memberships project_memberships = db.query(ProjectMember).filter(ProjectMember.user_id == user_id).count() # Check for task assignments task_assignments = db.query(Task).filter(Task.assigned_user_id == user_id).count() can_delete = project_memberships == 0 and task_assignments == 0 reason = None if not can_delete: reasons = [] if project_memberships > 0: reasons.append(f"{project_memberships} project membership(s)") if task_assignments > 0: reasons.append(f"{task_assignments} task assignment(s)") reason = f"User has {' and '.join(reasons)}. Please remove user from projects and reassign tasks first." return { "can_delete": can_delete, "reason": reason, "project_memberships": project_memberships, "task_assignments": task_assignments } @router.delete("/{user_id}", response_model=dict) async def admin_delete_user( user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_admin_permission_with_db) ): """Delete user account (Admin permission required).""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) # Prevent admin from deleting themselves if user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete your own account" ) # Check for project memberships project_memberships = db.query(ProjectMember).filter(ProjectMember.user_id == user_id).count() if project_memberships > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot delete user - user has {project_memberships} project membership(s). Please remove user from projects first." ) # Check for task assignments task_assignments = db.query(Task).filter(Task.assigned_user_id == user_id).count() if task_assignments > 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Cannot delete user - user has {task_assignments} task assignment(s). Please reassign tasks first." ) # Delete the user db.delete(user) db.commit() return { "message": f"User {user.email} deleted successfully", "user_id": user_id } @router.post("/me/avatar", response_model=UserResponse) async def upload_avatar( file: UploadFile = File(...), token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Upload user avatar image.""" from utils.file_handler import file_handler current_user = _get_user_from_db(db, token_data["user_id"]) # Validate file format allowed_formats = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} file_extension = Path(file.filename).suffix.lower() if file_extension not in allowed_formats: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid file format. Allowed formats: {', '.join(allowed_formats)}" ) # Read file content file_content = await file.read() file_size = len(file_content) # Validate file size (5MB max) max_size = 5 * 1024 * 1024 # 5MB if file_size > max_size: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File too large. Maximum size is 5MB" ) # Create avatars directory using FileHandler's base structure avatars_dir = file_handler.base_upload_dir / "avatars" avatars_dir.mkdir(parents=True, exist_ok=True) # Generate unique filename timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") hash_input = f"{current_user.id}{timestamp}".encode() short_hash = hashlib.md5(hash_input).hexdigest()[:8] unique_filename = f"user_{current_user.id}_{timestamp}_{short_hash}{file_extension}" file_path = avatars_dir / unique_filename # Save temporary file temp_path = avatars_dir / f"temp_{unique_filename}" with open(temp_path, "wb") as buffer: buffer.write(file_content) try: # Process image: resize and crop to 200x200 with Image.open(temp_path) as img: # Convert to RGB if necessary if img.mode in ('RGBA', 'LA', 'P'): # For RGBA, create white background if img.mode == 'RGBA': background = Image.new('RGB', img.size, (255, 255, 255)) background.paste(img, mask=img.split()[3]) # Use alpha channel as mask img = background else: img = img.convert('RGB') # Resize to 200x200 (crop to square first) width, height = img.size min_dimension = min(width, height) # Crop to square from center left = (width - min_dimension) // 2 top = (height - min_dimension) // 2 right = left + min_dimension bottom = top + min_dimension img = img.crop((left, top, right, bottom)) # Resize to 200x200 img = img.resize((200, 200), Image.Resampling.LANCZOS) # Save processed image img.save(file_path, 'JPEG', quality=90) # Delete temporary file os.remove(temp_path) # Delete old avatar if exists if current_user.avatar_url: # Resolve old avatar path and delete old_avatar_absolute_path = file_handler.resolve_absolute_path(current_user.avatar_url) if os.path.exists(old_avatar_absolute_path): try: os.remove(old_avatar_absolute_path) except Exception: pass # Ignore errors deleting old avatar # Store relative path in database using FileHandler relative_avatar_path = file_handler.store_relative_path(str(file_path)) current_user.avatar_url = relative_avatar_path db.commit() db.refresh(current_user) return current_user except Exception as e: # Clean up temporary file on error if temp_path.exists(): os.remove(temp_path) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to process image: {str(e)}" ) @router.delete("/me/avatar", response_model=UserResponse) async def remove_avatar( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Remove user avatar image.""" from utils.file_handler import file_handler current_user = _get_user_from_db(db, token_data["user_id"]) # Delete avatar file if exists if current_user.avatar_url: # Resolve to absolute path for file deletion avatar_absolute_path = file_handler.resolve_absolute_path(current_user.avatar_url) if os.path.exists(avatar_absolute_path): try: os.remove(avatar_absolute_path) except Exception: pass # Ignore errors deleting avatar file # Clear avatar URL current_user.avatar_url = None db.commit() db.refresh(current_user) return current_user @router.put("/me/password", response_model=dict) async def change_password( password_data: UserPasswordChange, token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Change current user's password.""" current_user = _get_user_from_db(db, token_data["user_id"]) # Verify current password if not pwd_context.verify(password_data.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect current password" ) # Validate new password (basic validation) if len(password_data.new_password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 8 characters long" ) # Hash and update password current_user.password_hash = pwd_context.hash(password_data.new_password) db.commit() return { "message": "Password changed successfully", "user_id": current_user.id }