d932134975
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
- New DELETE /users/{id}: hard-deletes a user only if they have zero
activity footprint (no tests, evidence, worklogs, audit entries, Jira
links, procedure/template suggestions, reviewed imports) — otherwise
rejects with a clear message to deactivate instead, to keep the audit
trail intact. Cleans up the user's own notifications/password-setup
tokens as part of deletion. Self-delete is blocked. Wired to a trash
icon in the Users page with a confirm dialog.
- Registered the EvaluationImport model in app/models/__init__.py — it
was missing, so its table never existed in the SQLite test DB; only
surfaced once the new delete-user footprint check queried it.
- Fixed a redirect bug: the axios response interceptor's on-401 redirect
to /login didn't exempt /set-password, so the auth provider's routine
mount-time /auth/me check (401 for a logged-out visitor) bounced anyone
opening their emailed set-password link away before they could use it.
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""User management router (admin only)."""
|
|
|
|
# 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"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
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}")
|