"""User management router (admin only).""" # Import logging import logging # Import uuid import uuid # Import APIRouter, Depends, status from fastapi from fastapi import APIRouter, Depends, status # Import Session from sqlalchemy.orm from sqlalchemy.orm import Session # Import get_db from app.database from app.database import get_db # Import require_role from app.dependencies.auth from app.dependencies.auth import require_role, require_any_role_strict # Import UnitOfWork from app.domain.unit_of_work from app.domain.unit_of_work import UnitOfWork from app.domain.errors import BusinessRuleViolation # Import User from app.models.user from app.models.user import User from app.dependencies.auth import get_current_user from app.schemas.user import ( UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut, SendPasswordEmailOut, SwitchRoleRequest, ) from app.services.audit_service import log_action # Import from app.services.user_service from app.services.user_service import ( create_user_without_password, delete_user, get_user_or_raise, list_users, switch_active_role, update_user, ) from app.services.password_setup_service import send_password_setup_email # Assign router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"]) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # PATCH /users/me/preferences — update current user preferences # --------------------------------------------------------------------------- @router.patch("/me/preferences", response_model=UserOut) def update_my_preferences( payload: UserPreferencesUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update the current user's notification preferences, Jira account ID and Jira API token. Send ``jira_api_token: ""`` to clear a previously stored token. The token is never returned in any response. """ update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): if field in ("jira_api_token", "jira_email", "tempo_api_token"): # Empty string means "clear the value" setattr(current_user, field, value if value else None) else: setattr(current_user, field, value) db.commit() db.refresh(current_user) return current_user # --------------------------------------------------------------------------- # GET /users/me — get current user's own profile # --------------------------------------------------------------------------- @router.get("/me", response_model=UserOut) def get_me( current_user: User = Depends(get_current_user), ): """Return the currently authenticated user's profile.""" return current_user # --------------------------------------------------------------------------- # POST /users/me/switch-role — swap the caller's active role # --------------------------------------------------------------------------- @router.post("/me/switch-role", response_model=UserOut) def switch_my_role( payload: SwitchRoleRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> UserOut: """Switch the caller's active role to one of their granted extra_roles. Roles never mix — this changes which single role is active, it never grants permissions from more than one role at once. Takes effect immediately since every permission check reads ``role`` fresh from the DB on each request. """ with UnitOfWork(db) as uow: user = switch_active_role(db, current_user, payload.role) log_action( db, user_id=current_user.id, action="switch_role", entity_type="user", entity_id=current_user.id, details={"new_role": payload.role}, ) uow.commit() db.refresh(user) return user # --------------------------------------------------------------------------- # GET /users — list all users # --------------------------------------------------------------------------- @router.get("", response_model=list[UserOut]) # Define function list_users_route def list_users_route( # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_role("admin")), ) -> list[UserOut]: """Return a list of all users. **Requires admin role.**.""" # Return list_users(db) return list_users(db) # --------------------------------------------------------------------------- # POST /users — create a new user # --------------------------------------------------------------------------- @router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) # Define function create_user_route def create_user_route( # Entry: payload payload: UserCreate, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_role("admin")), ) -> UserOut: """Create a new user. **Requires admin role.**. No password is set — use ``POST /users/{id}/send-password-email`` afterward to let the user set their own via a one-time link. """ # Open context manager with UnitOfWork(db) as uow: user = create_user_without_password( db, full_name=payload.full_name, email=payload.email, role=payload.role, ) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="create_user", # Keyword argument: entity_type entity_type="user", # Keyword argument: entity_id entity_id=user.id, # Keyword argument: details details={"full_name": user.full_name, "email": user.email, "role": user.role}, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(user) # Send the set-password email right away — best-effort. If the webhook # isn't configured or rejects the request, the user is still created # successfully; the admin can retry via the "Send Email" button, which # surfaces the failure properly (unlike this automatic first attempt). try: with UnitOfWork(db) as uow: send_password_setup_email(db, user) uow.commit() except Exception: logger.warning("Automatic set-password email failed for new user %s", user.id, exc_info=True) from app.services.notification_service import notify_roles_by_email notify_roles_by_email( db, roles=["admin"], preference_key="email_on_new_users", subject=f"New User Created: {user.full_name or user.email}", message=( f'A new user account was created: {user.full_name or user.email} ' f"({user.email}), role: {user.role}." ), exclude_user_id=current_user.id, ) # Return user return user # --------------------------------------------------------------------------- # GET /users/operators — minimal operator/lead list for assignment pickers # --------------------------------------------------------------------------- @router.get("/operators", response_model=list[UserOperatorOut]) def list_operators_route( db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")), ) -> list[UserOperatorOut]: """Return active red/blue operators and leads, for lead/manager assignment pickers. Not reachable by admin — admin administers the site, leads/managers coordinate operators, and admin cannot itself be assigned as an operator either. Returns only id/username/role — no emails or tokens — since this is reachable by non-admin leads. """ return ( db.query(User) .filter( User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]), User.is_active.is_(True), ) .order_by(User.username) .all() ) # --------------------------------------------------------------------------- # GET /users/{id} — get a single user # --------------------------------------------------------------------------- @router.get("/{user_id}", response_model=UserOut) # Define function get_user def get_user( # Entry: user_id user_id: uuid.UUID, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_role("admin")), ) -> UserOut: """Return a single user by ID. **Requires admin role.**.""" # Return get_user_or_raise(db, user_id) return get_user_or_raise(db, user_id) # --------------------------------------------------------------------------- # PATCH /users/{id} — update a user # --------------------------------------------------------------------------- @router.patch("/{user_id}", response_model=UserOut) # Define function update_user_route def update_user_route( # Entry: user_id user_id: uuid.UUID, # Entry: payload payload: UserUpdate, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_role("admin")), ) -> UserOut: """Update one or more fields of an existing user. **Requires admin role.**.""" # Assign update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True) # An admin can never strip their own admin permission — otherwise a # careless self-edit (or a compromised session) could lock every admin # out of the platform with no way back in. if user_id == current_user.id and current_user.role == "admin": final_role = update_data.get("role", current_user.role) final_extra_roles = update_data.get("extra_roles", current_user.extra_roles or []) if "admin" not in {final_role, *(final_extra_roles or [])}: raise BusinessRuleViolation("You cannot remove your own admin permission.") # Open context manager with UnitOfWork(db) as uow: # Assign user = update_user(db, user_id, **update_data) user = update_user(db, user_id, **update_data) # Call log_action() log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="update_user", # Keyword argument: entity_type entity_type="user", # Keyword argument: entity_id entity_id=user.id, # Keyword argument: details details={"updated_fields": list(update_data.keys())}, ) # Call uow.commit() uow.commit() # Reload ORM object attributes from the database db.refresh(user) # Return user return user # --------------------------------------------------------------------------- # DELETE /users/{id} — permanently delete a user # --------------------------------------------------------------------------- @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_user_route( user_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ) -> None: """Permanently delete a user. **Requires admin role.**. Only users with no activity footprint (no tests, evidence, worklogs, audit entries, etc.) can be hard-deleted — anyone else must be deactivated instead, to preserve the audit trail. """ with UnitOfWork(db) as uow: delete_user(db, user_id, current_user.id) log_action( db, user_id=current_user.id, action="delete_user", entity_type="user", entity_id=user_id, details={}, ) uow.commit() # --------------------------------------------------------------------------- # POST /users/{id}/send-password-email — set-password / reset link # --------------------------------------------------------------------------- @router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut) def send_password_email_route( user_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ) -> SendPasswordEmailOut: """Email a one-time set-password link to a user. **Requires admin role.**. Works for both a freshly-created passwordless user and an existing one whose password needs resetting — same link, same flow either way. """ user = get_user_or_raise(db, user_id) with UnitOfWork(db) as uow: send_password_setup_email(db, user) log_action( db, user_id=current_user.id, action="send_password_setup_email", entity_type="user", entity_id=user.id, details={}, ) uow.commit() return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")