604 lines
20 KiB
Python
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
|
|
} |