diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1748f58484..2abb946079 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,7 +1,10 @@ +import csv +import io import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse from sqlmodel import col, delete, func, select from app import crud @@ -159,6 +162,43 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any: return user +@router.get( + "/export", + dependencies=[Depends(get_current_active_superuser)], + response_class=StreamingResponse, +) +def export_users(session: SessionDep) -> StreamingResponse: + """ + Export all users as a downloadable CSV file. Superusers only. + """ + statement = select(User).order_by(col(User.created_at).desc()) + users = session.exec(statement).all() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow( + ["id", "email", "full_name", "is_active", "is_superuser", "created_at"] + ) + for user in users: + writer.writerow( + [ + str(user.id), + user.email, + user.full_name or "", + user.is_active, + user.is_superuser, + user.created_at.isoformat() if user.created_at else "", + ] + ) + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="users.csv"'}, + ) + + @router.get("/{user_id}", response_model=UserPublic) def read_user_by_id( user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py index 9c4cdd5991..73f5560533 100644 --- a/backend/tests/api/routes/test_users.py +++ b/backend/tests/api/routes/test_users.py @@ -448,6 +448,43 @@ def test_delete_user_me(client: TestClient, db: Session) -> None: assert user_db is None +def test_export_users_as_superuser( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + # Create a known user so there is at least one row in the export + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + crud.create_user(session=db, user_create=user_in) + + r = client.get( + f"{settings.API_V1_STR}/users/export", + headers=superuser_token_headers, + ) + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + assert "attachment" in r.headers["content-disposition"] + assert "users.csv" in r.headers["content-disposition"] + + lines = r.text.strip().splitlines() + assert lines[0] == "id,email,full_name,is_active,is_superuser,created_at" + assert len(lines) >= 2 # header + at least the user we created + + # Verify the created user appears in the CSV + emails_in_csv = [line.split(",")[1] for line in lines[1:]] + assert username in emails_in_csv + + +def test_export_users_forbidden_for_normal_user( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get( + f"{settings.API_V1_STR}/users/export", + headers=normal_user_token_headers, + ) + assert r.status_code == 403 + + def test_delete_user_me_as_superuser( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..807947228a 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersExportUsersResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -365,6 +365,19 @@ export class UsersService { }); } + /** + * Export Users + * Export all users as a downloadable CSV file. Superusers only. + * @returns unknown Successful Response + * @throws ApiError + */ + public static exportUsers(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/export' + }); + } + /** * Read User By Id * Get a specific user by id. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..b31d99decd 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -212,6 +212,8 @@ export type UsersRegisterUserData = { export type UsersRegisterUserResponse = (UserPublic); +export type UsersExportUsersResponse = (unknown); + export type UsersReadUserByIdData = { userId: string; };