LinkDesk/backend/routers/users.py

604 lines
20 KiB
Python

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
}