Init Repo
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user